#!/usr/bin/env ruby

require 'thread'
require 'monitor'
require 'time'

require 'simmon/logging'

module Simmon

  # Represents the current state of a sensor (read-only). The
  # timestamp represents the time at which the state was obtained from
  # whatever is being monitored. The ok flag is simply an indication
  # of whether the sensor itself is indicating a problem. The value
  # can be any value that can be marshalled by the Marshal class, or
  # nil if there is no value to report. The message is a
  # human-readable message (string) for human purposes.
  class State
    attr_reader :timestamp, :ok, :value, :message

    def initialize(ok, value = nil, message = nil)
      @timestamp = Time.now()
      @ok = ok
      @value = value
      @message = message
    end

    def to_s()
      str = ""
      if @ok then
        str = "OK"
      else
        str = "FAILURE"
      end

      if @value or @message then
        str = "#{str}:"
      end
      
      if @value then
        # ugly hack to un-screw the case where the value is an array, since ruby < 1.9 
        # totally special-cases their printing
        if @value.kind_of?(Array) then
          str = "#{str} ["
          @value.each() { |v| str = "#{str}#{v}," }
          if str[str.size() - 1,str.size() - 1] == ',' then
            str = str[0..str.size() - 2]
          end
          str = "#{str}]"
        else
          str = "#{str} [#{@value}]"
        end
      end

      if @message then
        str = "#{str} \"#{@message}\""
      end

      str
    end
  end

  # A Sensor is the basic method by which things are monitored. A
  # sensor has associated a name, a state, an interval and a maximum
  # acceptable lag time. The Sensor class defines the public interface
  # implemented by all sensors, and provides a base class with most of
  # the functionality needed for concrete sensors. Subclasses need
  # only implement the sample() method.
  #
  # The name is a human-readable short name for the sensor, for use
  # in human-readable messages and for logging purposes.
  #
  # The initial state defines the state the sensor is on upon creation,
  # before a sample has been collected.
  #
  # The interval is the intended interval of each sample taken by
  # the sensor. For an interval n, an attempt to sample will be initiated
  # every n seconds (as opposed to n seconds after the completion of
  # the previous sample), except if the previous sampling has not yet
  # completed. Thus, at any given moment in time there will be exactly zero
  # or one sampling attempt in progress.
  #
  # The maximum acceptable lag time defines the maximum time that may elapse
  # after a successful sample, before the result of the previous sample
  # is discarded and the sensor is considered to be in a failed state.
  #
  # The intent is for concrete sensors to be fairly simple, not having to
  # worry about timing issues or implementing timeouts. For example, a
  # sendor performing an HTTP request can do just that; if the request
  # gets stuck in a tarpit the sensor will still fail after the maxmimum
  # lag period.
  class Sensor
    attr_reader :name

    def initialize(name,
                   interval = 60,
                   maxlag = interval * 2,
                   initial_state = State.new(true))
      @name = name
      @state = initial_state
      @interval = interval
      @maxlag = maxlag

      @state_listeners = []
      @up_listeners    = []
      @down_listeners  = []
      @state_change_listeners = []

      @monitor = Monitor.new()
      @cond = @monitor.new_cond()
      @stop = false
      @sampling_stopped = true
      @watchdog_stopped = true

      @log = Log4r::Logger['simmon']
    end

    # Add the given mailbox to the list of mailboxes that get notified
    # when the state is updated (regardless of whether it was
    # changed). The object delivered to the mailbox is [sensor,
    # state].
    def add_state_listener(mailbox)
      @monitor.synchronize() do
        @state_listeners.push(mailbox)
      end
    end

    # Remove a previously registered state listener.
    def remove_state_listener(mailbox)
      @monitor.synchronize() do
        @state_listeners.delete(mailbox)
      end
    end
    
    # Like add_state_listener, but the mailbox will only be notified
    # when the state change involves a transition from the down
    # (non-ok) to up (ok).
    def add_up_listener(mailbox)
      @monitor.synchronize() do
        @up_listeners.push(mailbox)
      end
    end

    # Remove a previously added up listener.
    def remove_up_listener(mailbox)
      @monitor.synchronize() do
        @up_listeners.delete(mailbox)
      end
    end
    
    # Like add_state_listener, but the mailbox will only be notified
    # when the state change involves a transition from the up (ok)
    # state to down (non-ok).
    def add_down_listener(mailbox)
      @monitor.synchronize() do
        @down_listeners.push(mailbox)
      end
    end

    # Remove a previously added down listener.
    def remove_down_listener(mailbox)
      @monitor.synchronize() do
        @down_listeners.delete(mailbox)
      end
    end

    # Like add_state_listener, but the mailbox will only be notified
    # when the state change involves a transition from the up (ok)
    # state to down (non-ok), or from the down state to the up state.
    def add_state_change_listener(mailbox)
      @monitor.synchronize() do
        @state_change_listeners.push(mailbox)
      end
    end

    # Remove a previously added down listener.
    def remove_state_change_listener(mailbox)
      @monitor.synchronize() do
        @state_change_listeners.delete(mailbox)
      end
    end

    # Convenience method to emit an event to listeners. The listeners
    # should be one of the instance listener arrays, and @monitor must
    # be held.
    def notify_listeners(listeners, event)
      listeners.each() do |l|
        l.put(event)
      end
    end

    # Start the sensor (initiate sampling).
    def start()
      @monitor.synchronize() do
        if (not @sampling_stopped) or (not @watchdog_stopped) then
          raise SensorError("sensor is already started - refusing to start again")
        end
        @sampling_stopped = false
        @watchdog_stopped = false
      end
      
      Thread.new() { sample_loop()   }
      Thread.new() { watchdog_loop() }
    end
    
    def sample_loop()
      begin
        target_time = Time.now()
        
        while true
          now = Time.now()
          @monitor.synchronize() do
            while now < target_time and not @stop do
              @cond.wait(target_time - now)
              now = Time.now()
            end    
            
            if @stop then
              @sampling_stopped = true
              @cond.broadcast()
              break
            end
          end
          
          state = nil
          begin
            @log.debug("sampling #{@name}")
            state = self.sample()
            
            if not state.kind_of?(State) then
              # we don't allow ducktyping here, because it might break
              # the "front end" of the sensor
              state = State.new(false, nil, "sampling failed - state was of type #{state.class()}; expecting kind of State")
            end
          rescue
            state = State.new(false, nil, "sampling failed: #{$!}")
            
            @log.warn("sensor #{@name} sample failed: #{$!}")
            @log.info("sensor #{@name} failure backtrace: #{$!.backtrace()}")
          end
      
          set_state(state)
          
          now = Time.now()
          ideal_target = target_time + @interval
          
          if ideal_target < now then
            target_time = now
          else
            target_time = ideal_target
          end
        end
      rescue
        # this should not happen except if there is a bug in the sensor code
        # itself, or something otherwise very extreme is going on
        @log.fatal("sensor #{@name} thread death: #{$!} #{$!.backtrace()}")
        raise
      end
    end

    # enforce the 'maximum lag' property, changing the state to failed
    # when the last known state expires
    def watchdog_loop()
      begin
        @log.debug("watchdog starting for sensor #{@name}")
        while true
          now = Time.now()
          deadline = nil

          @monitor.synchronize() do
            deadline = @state.timestamp + @maxlag
            if now > deadline then
              set_state(State.new(false, nil, "no sample within the last #{@maxlag} seconds"))
              deadline = @state.timestamp + @maxlag
            end

            if @stop then
              @watchdog_stopped = true
              @cond.broadcast()
              break
            end
          end
          
          sleep(deadline - now)
        end
        @log.debug("watchdog loop for sensor #{@name} exiting as requested")
      rescue
        @log.fatal("watchdog loop for sensor #{@name} exiting: #{$!} #{$!.baktrace()}")
      end
    end

    def set_state(state)
      oldstate = nil
      @monitor.synchronize() do
        oldstate = @state
        @state = state

        notify_listeners(@state_listeners, [self, state])

        if oldstate.ok() and (not state.ok()) then
          notify_listeners(@down_listeners, [self, state])
          notify_listeners(@state_change_listeners, [self, state])
        elsif (not oldstate.ok()) and state.ok() then
          notify_listeners(@up_listeners, [self, state])
          notify_listeners(@state_change_listeners, [self, state])
        end
      end
      
      if oldstate.ok() != state.ok() then
        @log.warn("sensor state change: #{@name}: #{state}")
      end
    end
    
    # Stop the sensor (wait for any currently running sampling to
    # complete, and stop any new samplings from being performed). Note
    # that this method may block for as long as it takes for the
    # sensors sampling attempt to complete (which may mean
    # indefinitely, depending on the implementation of the sensor).
    def stop()
      @monitor.synchronize() do
        @stop = true

        while not @stopped do
          @cond.wait()
        end
      end
    end

    def state()
      @monitor.synchronize() do
        diff = Time.now() - @state.timestamp

        if diff > (@maxlag * 2) then
          @log.fatal("INTERNAL PROBLEM: #{@name} timeout - no sample in #{diff} "\
                     "seconds (this should have been taken care of by a background "\
                     "thread; this message indicates an internal bug or that the "\
                     "system is overloaded))")
          @state = State.new(false, nil, "timeout - no sample in #{diff} seconds")
        end

        @state
      end
    end
 
    def state=(state)
      @monitor.synchronize() do
        @state = state
      end
    end

    # Take a sample, returning a State instance representing the
    # result of the sample. Must be implemented by subclasses.
    def sample()
      raise NotImpelemntedError("#{self.class.name}#sample() is not implemented")
    end
  end

  # A sensor which uses a block, provided upon construction, for
  # sampling, thus allowing the creation of custom sensors without
  # creating dedicated classes.
  class BlockSensor < Sensor
    def initialize(name,
                   interval = 60,
                   maxlag = interval * 2,
                   initial_state = State.new(true),
                   &sampler)
      super(name, interval, maxlag, initial_state)

      @sampler = sampler
    end

    def sample()
      @sampler.call()
    end
  end

  # A sensor which in turn samples multiple sensors.
  class CompoundSensor < Sensor
    # Takes a block (policy) which takes two parameters - the number
    # of sensors in OK state and the total number of sensors. The
    # block returns whether the state of the compound sensor is to be
    # considered OK.
    def initialize(name,
                   interval = 60,
                   maxlag = interval * 2,
                   initial_state = State.new(true),
                   &policy)
      super(name, interval, maxlag, initial_state)

      @policy = policy
      @sensors = []

      @log = Log4r::Logger['simmon']
    end

    def register_sensor(s)
      @sensors << s
    end

    def sample()
      tot = 0
      ok = 0

      @sensors.each() do |s|
        tot = tot + 1
        if s.state().ok() then
          ok = ok + 1
        end
      end

      if @policy.call(ok, tot) then
        State.new(true, nil, "Sensors OK according to policy.")
      else
        State.new(false, nil, "Sensors not OK according to policy.")
      end
    end
  end
end
